20 — 物理
介绍
物理可以是你添加到 WebGL 体验里最酷的功能之一。人们喜欢摆弄物体,看着它们碰撞、倒塌、下落和弹跳,就像我的作品集里那样:https://bruno-simon.com/
给项目添加物理有很多种方式,这取决于你想实现什么。你可以用一些数学计算和像 Raycaster 这样的解决方案自己造物理,但如果你想要带有张力、摩擦、弹跳、约束、枢轴等的真实物理,而且全部都在 3D 空间里,你最好用一个库。
理论
思路很简单。我们要创建一个物理世界。这个物理世界纯粹是理论层面的,我们看不见它。但在这个世界里,物体会下落、碰撞、摩擦、滑动等等。
当我们创建了一个 Three.js mesh 时,我们也会在物理世界里创建那个 mesh 的一个版本。如果我们在 Three.js 里造一个盒子,我们也会在物理世界里造一个盒子。
然后,在每一帧,渲染任何东西之前,我们让物理世界更新它自己;我们取物理物体的坐标(位置和旋转),并把它们应用到对应的 Three.js mesh 上。
仅此而已。最难的其实是把我们的代码组织成一个体面的结构。这正是分道扬镳的地方。每个开发者都有自己的习惯,而且这也取决于你想做什么,以及物理可以变得有多复杂。
开始的时候,我们会简单地只创建球体和盒子。
库
有多个可用的库。首先,你必须决定你需要的是 3D 库还是 2D 库。虽然你可能会认为必须是 3D 库,因为 Three.js 全都是关于 3D 的,但你可能是错的。2D 库通常性能好得多,如果你能把你的体验物理归结为 2D 碰撞,你最好用 2D 库。
一个例子是如果你想做一个台球游戏。球可以互相碰撞并在墙上反弹,但你可以把一切都投影到一个 2D 平面上。你可以在物理世界里把球设计成圆,墙面就是简单的矩形。确实,你将无法做到击打球的底部、让它跳过其他球这种花活。
一个这样完成项目的绝佳例子是 Ouigo Let's play,作者是 Merci Michel。他们用了 2D 物理库,因为每次碰撞和动画都可以在 2D 空间里表示。
3D 物理
对于 3D 物理,有三个主要的库:
Ammo.js
- 网站:http://schteppe.github.io/ammo.js-demos/
- Git 仓库:https://github.com/kripken/ammo.js/
- 文档:没有文档
- Bullet(一个用 C++ 编写的物理引擎)的直接 JavaScript 移植
- 有点重
- 仍有社区在更新
Cannon.js
- 网站:https://schteppe.github.io/cannon.js/
- Git 仓库:https://github.com/schteppe/cannon.js
- 文档:http://schteppe.github.io/cannon.js/docs/
- 比 Ammo.js 更轻
- 比 Ammo.js 更容易实现
- 主要只由一个开发者维护
- 很多年没有更新了
- 有一个在维护的 fork
Oimo.js
- 网站:https://lo-th.github.io/Oimo.js/
- Git 仓库:https://github.com/lo-th/Oimo.js
- 文档:http://lo-th.github.io/Oimo.js/docs.html
- 比 Ammo.js 更轻
- 比 Ammo.js 更容易实现
- 主要只由一个开发者维护
- 已经 2 年没有更新了
Rapier
- 网站:https://rapier.rs/
- Git 仓库:https://github.com/dimforge/rapier
- 文档:https://rapier.rs/javascript3d/index.html
- 和 Cannon.js 非常相似
- 性能好
- 正在持续维护
2D 物理
对于 2D 物理,有很多库,但这里是最受欢迎的:
Matter.js
- 网站:https://brm.io/matter-js/
- Git 仓库:https://github.com/liabru/matter-js
- 文档:https://brm.io/matter-js/docs/
- 主要只由一个开发者维护
- 某种程度上仍在更新
P2.js
- 网站:https://schteppe.github.io/p2.js/
- Git 仓库:https://github.com/schteppe/p2.js
- 文档:http://schteppe.github.io/p2.js/docs/
- 主要只由一个开发者维护(和 Cannon.js 同一位)
- 已经 2 年没有更新了
Planck.js
- 网站:https://piqnt.com/planck.js/
- Git 仓库:https://github.com/shakiba/planck.js
- 文档:https://github.com/shakiba/planck.js/tree/master/docs
- 主要只由一个开发者维护
- 如今仍在更新
Box2D.js
- 网站:http://kripken.github.io/box2d.js/demo/webgl/box2d.html
- Git 仓库:https://github.com/kripken/box2d.js/
- 文档:没有文档
- 主要只由一个开发者维护(和 Ammo.js 同一位)
- 如今仍在更新
Rapier
- 网站:https://rapier.rs/
- Git 仓库:https://github.com/dimforge/rapier
- 文档:https://rapier.rs/javascript2d/index.html
- 和 3D 的是同一个库
这节课我们不会用 2D 库,但 2D 库的代码会与 3D 库的代码非常相似。主要区别是你需要更新的轴。
已经有一些尝试把 Three.js 与像 Physijs 这样的库结合的方案。但我们不会使用这些方案中的任何一种,以获得更好的学习体验,并更好地理解底下发生了什么。
虽然 Ammo.js 是最常用的库,尤其是和 Three.js 一起用的时候,正如你可以在这些示例里看到的,但我们会选 Cannon.js。这个库在我们的项目里更容易实现,也更易用。
导入 Cannon.js
要往我们的项目里添加 Cannon.js,我们首先需要添加依赖。
在你的终端里,在项目文件夹中,运行这个命令 npm install cannon。
我们现在可以在 JavaScript 里用一个经典的 import 导入 Cannon.js 了:
import CANNON from 'cannon' 我们需要的一切都可在 CANNON 变量里找到。
设置
我们的初始项目由一个放在平面上的球体组成,而且已经从美学的角度启用了阴影。
基础
世界
首先,我们需要创建一个 Cannon.js World:
/**
* Physics
*/
const world = new CANNON.World() 我们可以做一个没有重力的太空 WebGL 体验,但让我们脚踏实地,添加重力。你可以用 gravity 属性改变这个值,它是一个 Cannon.js Vec3。
Cannon.js 的 Vec3 就像 Three.js 的 Vector3。它有 x、y 和 z 属性,但也有一个 set(...) 方法:
world.gravity.set(0, - 9.82, 0) 我们用 - 9.82 作为这个值,因为它是地球上的重力常数,但如果你想让东西落得更慢,或者如果你的场景发生在火星上,你可以用任何其他值。
物体
因为我们的场景里已经有一个球体,让我们在我们 Cannon.js 的 World 里创建一个球体。
要做到这一点,我们必须创建一个 Body。Body 就是那些会下落并与其他 Body 碰撞的对象。
在我们能创建一个 Body 之前,我们必须决定一个形状。有很多可用的基本形状,比如 Box、Cylinder、Plane 等等。我们会用一个 Sphere,和我们的 Three.js 球体有相同的半径:
const sphereShape = new CANNON.Sphere(0.5) 然后我们可以创建我们的 Body,并指定一个质量和位置:
const sphereBody = new CANNON.Body({
mass: 1,
position: new CANNON.Vec3(0, 3, 0),
shape: sphereShape
}) 最后,我们可以用 addBody(...) 把 Body 添加到世界:
world.addBody(sphereBody) 什么都不会发生,因为我们仍然需要更新我们的 Cannon.js 世界,并相应地更新我们的 Three.js 球体。
更新 Cannon.js 世界与 Three.js 场景
要更新我们的 World,我们必须使用 step(...)。这个方法背后的代码很难理解,我们这节课不会解释它,但你可以在这篇文章里找到更多相关内容。
要让它正常工作,你必须提供一个固定的时间步长、自上一次 step 以来经过的时间,以及世界为了赶上一个潜在延迟可以应用的迭代次数。
我们不会解释什么是时间步长,但因为我们要我们的体验以 60fps 运行,我们会用 1 / 60。别担心,这个体验在更高和更低帧率的设备上都会以同样的速度运行。
迭代次数由你决定,但如果体验顺畅运行,那就没那么重要。让我们用 3。
对于增量时间,就要稍微难一点了。我们需要计算自上一帧以来经过了多长时间。不要使用 Clock 类的 getDelta() 方法。你不会得到想要的结果,而且你会把类的内部逻辑搞乱。
要得到正确的增量时间,我们需要把上一帧的 elapsedTime 从当前的 elapsedTime 里减去:
const clock = new THREE.Clock()
let oldElapsedTime = 0
const tick = () =>
{
const elapsedTime = clock.getElapsedTime()
const deltaTime = elapsedTime - oldElapsedTime
oldElapsedTime = elapsedTime
// ...
} 我们终于可以更新我们的世界了:
const tick = () =>
{
// ...
// Update physics
world.step(1 / 60, deltaTime, 3)
} 仍然,似乎什么都一动不动。但我们的 sphereBody 正在下落,你可以通过在世界更新后记录它的位置看到这一点:
world.step(1 / 60, deltaTime, 3)
console.log(sphereBody.position.y) 我们现在要做的,是用 sphereBody 的坐标更新我们的 Three.js sphere。有两种方式。你可以分别更新每个 position 属性:
sphere.position.x = sphereBody.position.x
sphere.position.y = sphereBody.position.y
sphere.position.z = sphereBody.position.z 或者你可以用 copy(...) 方法把所有属性一次性复制:
sphere.position.copy(sphereBody.position) copy(...) 在很多类里都有,比如 Vector2、Vector3、Euler、Quaternion,甚至在像 Material、Object3D、Geometry 这样的类里也有。
你应该最终看到你的球体在下落。问题是我们的球体似乎穿过了地板。那是因为那个地板存在于 Three.js 场景里,但不存在于 Cannon.js 世界里。
我们可以简单地用一个 Plane 形状添加一个新的 Body,但我们不想让我们的地板受重力影响而下落。换句话说,我们要我们的地板是静态的。要让一个 Body 静态,把它 mass 设为 0:
const floorShape = new CANNON.Plane()
const floorBody = new CANNON.Body()
floorBody.mass = 0
floorBody.addShape(floorShape)
world.addBody(floorBody)
正如你看到的,这一次我们做的事情相当不同。我们创建了一个不带参数的 Body,之后才设置那些参数。结果是一样的,我们这样做唯一的原因是为了教学。一个有意思的事情是,你可以创建一个由多个 Shape 组成的 Body。这对于复杂但实心的物体可能很有用。
你应该看到球体朝一个方向弹出去(很可能是朝相机)。这不是想要的结果。原因是我们的平面默认朝着相机。我们需要旋转它,就像我们在 Three.js 里旋转地板那样。
用 Cannon.js 旋转比用 Three.js 稍微难一点,因为你必须使用 Quaternion。有多种方式旋转 Body,但必须通过它的 quaternion 属性。我们会用 setFromAxisAngle(...)。
第一个参数是一条轴。你可以把它想象成一根穿过 Body 的尖刺。第二个参数是角度。它是你绕着那根尖刺旋转 Body 的程度。
floorBody.quaternion.setFromAxisAngle(new CANNON.Vec3(- 1, 0, 0), Math.PI * 0.5)
我们设置这条轴,就好像它是一根沿着负 x 轴(相对相机来说是左侧)穿过 Body 的尖刺,然后我们把角度设为 Math.PI * 0.5(四分之一圈)。
你现在应该看到球体下落,然后停在地板上。
我们不需要用 Cannon.js 的地板更新 Three.js 的地板,因为这个物体不会动。
接触材质
正如你看到的,球没有弹跳太多。那是默认行为,我们可以用 Material(不是 Three.js 的 Material)和 ContactMaterial 改变它。
一个 Material 只是一个引用。你可以给它一个名字,并把它与一个 Body 关联起来。思路是为你场景里每一种材质类型创建一个 Material。
假设你世界里的所有东西都是用塑料做的。那样的话,你只需要创建一个材质,并把它命名为 'default' 或 'plastic'。如果你的场景里有多种类型的材质,比方说一种材质给地板,一种给球。那么,你应该创建不同的 Material,并给它们像 'concrete' 和 'plastic' 这样的名字。
你本可以叫它们 'ground' 和 'ball'。但如果你想把同样的材质用于墙面和其他物体比如立方体,用一个叫 'ground' 的材质可能会不方便。
在你创建球体和地板之前,创建这两个 Material:
const concreteMaterial = new CANNON.Material('concrete')
const plasticMaterial = new CANNON.Material('plastic') 现在我们有 Material 了,我们必须创建一个 ContactMaterial。它是两个 Material 的组合,并包含物体碰撞时要用的属性。
前两个参数是 Material。第三个参数是一个对象 {},包含两个重要属性:friction(摩擦)系数(摩擦有多大)和 restitution(恢复)系数(弹跳有多厉害)——两者的默认值都是 0.3。
创建之后,用 addContactMaterial(...) 方法把 ContactMaterial 添加到世界:
const concretePlasticContactMaterial = new CANNON.ContactMaterial(
concreteMaterial,
plasticMaterial,
{
friction: 0.1,
restitution: 0.7
}
)
world.addContactMaterial(concretePlasticContactMaterial) 混凝土和塑料之间没有太多摩擦,但如果你让一个塑料球落到混凝土地板上,你会看到它弹跳很多。
我们现在可以在 Body 上使用我们的 Material 了。你可以在实例化 Body 时直接传入 Material,或者在之后用 material 属性。为了教学,我们两种都做:
const sphereBody = new CANNON.Body({
// ...
material: plasticMaterial
})
// ...
const floorBody = new CANNON.Body()
floorBody.material = concreteMaterial
你应该看到球在停下来之前弹跳很多次。我们看不到 friction 在起作用,因为我们的球完美笔直地落在地板上,大部分时间都在空中。
拥有不同的 Material 并为每种组合创建 ContactMaterial 会被证明非常繁琐。为了简化一切,让我们把我们两个 Material 替换成一个默认的,并在每个 Body 上使用它:
const defaultMaterial = new CANNON.Material('default')
const defaultContactMaterial = new CANNON.ContactMaterial(
defaultMaterial,
defaultMaterial,
{
friction: 0.1,
restitution: 0.7
}
)
world.addContactMaterial(defaultContactMaterial)
// ...
const sphereBody = new CANNON.Body({
// ...
material: defaultMaterial
})
// ...
floorBody.material = defaultMaterial
我们得到相同的结果。
我们甚至可以更进一步,把我们材质设为 World 的默认材质。要做到这一点,只需把 defaultContactMaterial 赋值给 world 的 defaultContactMaterial 属性:
world.defaultContactMaterial = defaultContactMaterial 我们现在可以移除或注释掉对 sphereBody 和 floorBody 的材质赋值了。
施加力
有很多方式可以给一个 Body 施加力:
- applyForce 从空间中一个指定的点(不一定在 Body 的表面)给 Body 施加一个力,比如推着所有东西一直走的风、对多米诺骨牌一小下但突然的推、或者一个更大的突发力让愤怒的小鸟飞向敌人的城堡。
- applyImpulse 就像 applyForce,但不是加到会产生速度变化的力上,而是直接施加到速度上。
- applyLocalForce 和 applyForce 一样,但坐标是 Body 本地的(意思是
0, 0, 0就是 Body 的中心)。 - applyLocalImpulse 和 applyImpulse 一样,但坐标是 Body 本地的。
因为使用"force"方法会导致速度变化,所以我们不用"impulse"方法
让我们用 applyLocalForce(...) 在开始时给我们的 sphereBody 施加一个小小的冲量:
sphereBody.applyLocalForce(new CANNON.Vec3(150, 0, 0), new CANNON.Vec3(0, 0, 0))
你可以看到球向右弹跳并滚动。
现在让我们用 applyForce(...) 施加一点风。因为风是持续存在的,我们应该在每一帧、更新 World 之前施加这个力。要正确施加这个力,施加点应该是 sphereBody.position:
const tick = () =>
{
// ...
// Update physics
sphereBody.applyForce(new CANNON.Vec3(- 0.5, 0, 0), sphereBody.position)
world.step(1 / 60, deltaTime, 3)
// ...
}
处理多个物体
处理一两个物体很容易,但管理几十个物体可能一团糟。我们需要把事情自动化一点。
首先,移除或注释掉 sphere、sphereShape 和 sphereBody。
用函数自动化
首先,让我们用一个会同时添加 Three.js 和 Cannon.js 版本对象的函数,改进我们创建球体的方式。
作为这个函数的参数,我们只传入 radius 和 position,但随你自由添加其他参数,比如 mass、material、subdivisions 等等。
/**
* Utils
*/
const createSphere = (radius, position) =>
{
} 现在我们可以创建 Three.js 的 Mesh:
const createSphere = (radius, position) =>
{
// Three.js mesh
const mesh = new THREE.Mesh(
new THREE.SphereGeometry(radius, 20, 20),
new THREE.MeshStandardMaterial({
metalness: 0.3,
roughness: 0.4,
envMap: environmentMapTexture,
envMapIntensity: 0.5
})
)
mesh.castShadow = true
mesh.position.copy(position)
scene.add(mesh)
} 以及 Cannon.js 的 Body:
const createSphere = (radius, position) =>
{
// ...
// Cannon.js body
const shape = new CANNON.Sphere(radius)
const body = new CANNON.Body({
mass: 1,
position: new CANNON.Vec3(0, 3, 0),
shape: shape,
material: defaultMaterial
})
body.position.copy(position)
world.addBody(body)
} 注意,我们本可以在 CANNON.Body 实例化时直接使用 position。
我们可以移除之前创建的球体,然后调用 createSphere(...)(在创建 Cannon.js 世界和 Three.js 场景之后)。别忘了移除 tick() 函数里的球体更新:
createSphere(0.5, { x: 0, y: 3, z: 0 })
正如你看到的,位置不一定要是 Three.js 的 Vector3 或 Cannon.js 的 Vec3,我们可以简单地用一个带 x、y 和 z 属性的对象(我们很幸运)。
你应该看到球体悬停在地板上方,但遗憾的是,它不再动了。这完全正常,因为我们移除了那段把 Cannon.js Body 的 position 应用到 Three.js Mesh 的 position 的代码。
使用一个物体数组
为了处理这一部分,我们要创建所有需要更新的对象的数组。然后我们会把新创建的 Mesh 和 Body 放在一个对象里,加入那个数组:
const objectsToUpdate = []
const createSphere = (radius, position) =>
{
// ...
// Save in objects to update
objectsToUpdate.push({
mesh: mesh,
body: body
})
} 你可以像这样写最后这部分(在 JavaScript 里,当变量名相同的时候,不需要指定属性):
objectsToUpdate.push({ mesh, body }) 我们现在可以在 tick() 函数里(在更新世界之后立刻)遍历那个数组,并把每个 body.position 复制到 mesh.position:
const tick = () =>
{
// ...
world.step(1 / 60, deltaTime, 3)
for(const object of objectsToUpdate)
{
object.mesh.position.copy(object.body.position)
}
}
球体应该再次开始下落了。
添加到 lil-gui
让我们玩玩,给我们的 lil-gui 添加一个 createSphere 按钮。问题是,使用 gui.add(...) 方法时,第一个参数应该是一个对象,第二个参数应该是一个属性名。不幸的是,我们的 createSphere 方法不在一个对象里,而且还需要往它里面传参数。这种情况会经常发生。一个不那么坏的解决方案是创建一个对象,它唯一的用途就是把这些游离的函数作为属性放进去:
const gui = new GUI()
const debugObject = {} 然后在需要的时候往里面添加函数(在创建 createSphere 函数之后):
debugObject.createSphere = () =>
{
createSphere(0.5, { x: 0, y: 3, z: 0 })
} 最后,我们可以往 lil-gui 添加这个新的 createSphere 属性:
gui.add(debugObject, 'createSphere')
如果你点击新创建的 createSphere 按钮,你应该看到球体一个接一个地堆叠。那是因为球体正好出现在相同的位置。让我们添加一些随机性:
debugObject.createSphere = () =>
{
createSphere(
Math.random() * 0.5,
{
x: (Math.random() - 0.5) * 3,
y: 3,
z: (Math.random() - 0.5) * 3
}
)
}
球体下起雨来了!
尽量别把你的电脑烧掉;这段代码需要优化。
优化
因为 Three.js Mesh 的几何体和材质是相同的,我们应该把它们从 createSphere 函数里拿出来。问题是我们在用 radius 创建几何体。一个简单的解决方案是把 SphereGeometry 的半径固定为 1,然后缩放 Mesh:
const sphereGeometry = new THREE.SphereGeometry(1, 20, 20)
const sphereMaterial = new THREE.MeshStandardMaterial({
metalness: 0.3,
roughness: 0.4,
envMap: environmentMapTexture,
envMapIntensity: 0.5
})
const createSphere = (radius, position) =>
{
// Three.js mesh
const mesh = new THREE.Mesh(sphereGeometry, sphereMaterial)
mesh.castShadow = true
mesh.scale.set(radius, radius, radius)
mesh.position.copy(position)
scene.add(mesh)
// ...
} 你应该得到相同的结果。
添加盒子
既然我们的球体工作得很好,让我们对盒子做同样的流程。
要创建一个盒子,我们必须用一个 BoxGeometry 和一个 Box 形状。小心;这两个参数不一样。一个 BoxGeometry 需要一个 width、一个 height 和一个 depth。与此同时,一个 Box 形状需要一个 halfExtents。它由一个 Vec3 表示,对应一条从盒子中心出发、连向盒子其中一个角落的线段:
// Create box
const boxGeometry = new THREE.BoxGeometry(1, 1, 1)
const boxMaterial = new THREE.MeshStandardMaterial({
metalness: 0.3,
roughness: 0.4,
envMap: environmentMapTexture,
envMapIntensity: 0.5
})
const createBox = (width, height, depth, position) =>
{
// Three.js mesh
const mesh = new THREE.Mesh(boxGeometry, boxMaterial)
mesh.scale.set(width, height, depth)
mesh.castShadow = true
mesh.position.copy(position)
scene.add(mesh)
// Cannon.js body
const shape = new CANNON.Box(new CANNON.Vec3(width * 0.5, height * 0.5, depth * 0.5))
const body = new CANNON.Body({
mass: 1,
position: new CANNON.Vec3(0, 3, 0),
shape: shape,
material: defaultMaterial
})
body.position.copy(position)
world.addBody(body)
// Save in objects
objectsToUpdate.push({ mesh, body })
}
createBox(1, 1.5, 2, { x: 0, y: 3, z: 0 })
debugObject.createBox = () =>
{
createBox(
Math.random(),
Math.random(),
Math.random(),
{
x: (Math.random() - 0.5) * 3,
y: 3,
z: (Math.random() - 0.5) * 3
}
)
}
gui.add(debugObject, 'createBox')
别忘了移除第一次的 createSphere(...) 调用,否则你会同时在同一位置创建一个球体和一个盒子,那可能会乱成一团。
你应该看到一个盒子下落,然后突然穿过地板。如果你点击 lil-gui 的 createBox 按钮,这应该会更明显。
我们忘了一件大事:我们的 mesh 没有旋转。这里发生的是盒子在地板上弹跳,然后侧翻落地。但我们能看到的只是盒子直挺挺地站着并穿过地板,因为 Three.js Mesh 不像 Cannon.js Body 那样旋转。
我们之前没看到这个问题,因为我们用的是球体,无论怎么旋转它们看起来都一样。
我们可以通过把 Body 的 quaternion 复制到 Mesh 的 quaternion 来修复这个问题,就像我们对 position 做的那样:
const tick = () =>
{
// ...
for(const object of objectsToUpdate)
{
object.mesh.position.copy(object.body.position)
object.mesh.quaternion.copy(object.body.quaternion)
}
// ...
}
盒子现在应该正常下落了。你可以随心所欲地创建球体和盒子。像往常一样,尽量别把你的电脑烧掉。
性能
Broadphase
在测试物体之间的碰撞时,一个天真的做法是把每个 Body 和所有其他 Body 都测一遍。虽然这很容易做,但在性能上是昂贵的。
这正是 broadphase 登场的地方。broadphase 在测试之前对 Body 做一个粗略的分类。想象你有两堆相隔很远的盒子。你为什么要测一堆盒子与另一堆盒子碰撞?它们太远了,不可能碰撞。
Cannon.js 里有 3 种可用的 broadphase 算法:
- NaiveBroadphase:把每个 Body 与所有其他 Body 都测试一遍
- GridBroadphase:把世界划分成方格,只把 Body 与同一个格子或相邻格子里的其他 Body 测试。
- SAPBroadphase(sweep and prune broadphase):在多个步骤中,在任意轴上测试 Body。
默认的 broadphase 是 NaiveBroadphase,我建议你切换到 SAPBroadphase。使用这个 broadphase 最终有可能产生碰撞没发生的 bug,但这很罕见,而且它涉及非常快速移动 Body 这类情况。
要切换到 SAPBroadphase,只需在 world.broadphase 属性里实例化它,并且也要用同一个世界作为参数:
world.broadphase = new CANNON.SAPBroadphase(world)
睡眠
即使我们用了改进过的 broadphase 算法,所有的 Body 都会被测试,包括那些不再动的。我们可以使用一个叫 sleep 的特性。
当 Body 的速度变得极其缓慢(慢到你都看不见它在动)时,这个 Body 就可以入睡,并且不会被测试,除非通过代码对它施加足够的力,或者有另一个 Body 撞到它。
要激活这个特性,只需在 World 上把 allowSleep 属性设为 true:
world.allowSleep = true 你也可以用 sleepSpeedLimit 和 sleepTimeLimit 属性控制 Body 入睡的可能性,但我们不会改变那些。
事件
你可以在 Body 上监听事件。如果你想做像物体碰撞时播放声音这样的事,或者你想知道某颗抛射物是不是碰到了敌人,这都很有用。
你可以在 Body 上监听像 'colide'、'sleep' 或 'wakeup' 这样的事件。
当我们球体和盒子与任何东西碰撞时,让我们播放一个撞击声。首先,用原生 JavaScript 创建那个声音,并创建一个应该播放声音的函数。
一些像 Chrome 这样的浏览器会阻止声音播放,除非用户已经与页面有过交互,比如点击过任何地方,所以如果你听不到最开始的声音,别担心。
/**
* Sounds
*/
const hitSound = new Audio('/sounds/hit.mp3')
const playHitSound = () =>
{
hitSound.play()
} 光播放一个声音有点勉强,但我们稍后会给这个函数加更多内容。
现在,让我们在我们的 Body 上监听 'collide' 事件。我们将只专注于 createBox 函数,等完成后把它加到 createSphere 函数里。
现在,监听 collide 事件,并用 playHitSound 函数作为回调:
const createBox = (width, height, depth, position) =>
{
// ...
body.addEventListener('collide', playHitSound)
// ...
}
当立方体碰到地面或者立方体互相碰撞时,你应该听到撞击声。如果你用 Chrome,别忘了在盒子碰到地板之前点击一下页面,因为如果还没有用户交互发生,Chrome 会拒绝播放声音。
声音听起来相当不错。可惜,当我们添加多个盒子时会变得十分古怪。
第一个问题是,当声音正在播放时我们调用 hitSound.play(),什么都不会发生,因为它已经在播放了。我们可以用 currentTime 属性把声音重置到 0:
const playHitSound = () =>
{
hitSound.currentTime = 0
hitSound.play()
}
虽然一开始这让情况好了一些,但我们听到的撞击声太多了,哪怕只是立方体轻轻擦到另一个立方体。我们需要知道撞击有多强,如果不够强就不播放任何声音。
要得到撞击强度,我们首先需要获得碰撞的信息。我们可以通过给 'collide' 回调(也就是我们的 playHitSound 函数)添加一个参数来做到这一点:
const playHitSound = (collision) =>
{
console.log(collision)
// ...
} collision 变量现在包含一大堆信息。撞击强度可以在 contact 属性上调用 getImpactVelocityAlongNormal() 方法找到:
const playHitSound = (collision) =>
{
console.log(collision.contact.getImpactVelocityAlongNormal())
// ...
} 如果你看日志,你应该看到一个数字。撞击越强,数字越高。
我们测试那个值,并且只在 impactStrength 足够强时播放声音:
const playHitSound = (collision) =>
{
const impactStrength = collision.contact.getImpactVelocityAlongNormal()
if(impactStrength > 1.5)
{
hitSound.currentTime = 0
hitSound.play()
}
}
为了更逼真,我们可以给声音的音量添加一些随机性:
const playHitSound = (collision) =>
{
const impactStrength = collision.contact.getImpactVelocityAlongNormal()
if(impactStrength > 1.5)
{
hitSound.volume = Math.random()
hitSound.currentTime = 0
hitSound.play()
}
}
如果我们想更进一步,我们可以有多个略有不同的撞击声。而且为了防止有太多声音同时播放,我们可以添加一个非常短的延迟,让声音播放一次后在短时间内无法再次播放。
这节课我们不会做这些,但随你自由尝试。
让我们把我们用在 createBox 函数里的代码复制到 createSphere 函数里:
const createSphere = (radius, position) =>
{
// ...
body.addEventListener('collide', playHitSound)
// ...
}
这些声音应该对球体也有效了。
移除物体
让我们添加一个 reset 按钮。
创建一个 reset 函数,并按我们为 createBox 和 createSphere 所做的,把它加到你的 lil-gui:
// Reset
debugObject.reset = () =>
{
console.log('reset')
}
gui.add(debugObject, 'reset') 现在,让我们遍历 objectsToUpdate 数组里的每一个对象。然后把 object.body 从 world 里移除,把 object.mesh 从 scene 里移除。另外,别忘了像你在原生 JavaScript 里会做的那样移除 eventListener:
debugObject.reset = () =>
{
for(const object of objectsToUpdate)
{
// Remove body
object.body.removeEventListener('collide', playHitSound)
world.removeBody(object.body)
// Remove mesh
scene.remove(object.mesh)
}
}
我们也需要清空 objectsToUpdate 数组。在 JS 里有很多奇怪的清空数组的方式,其中之一是用 splice 方法把它的内容替换成空。
确保在 for 循环之后做这件事:
debugObject.reset = () =>
{
for(const object of objectsToUpdate)
{
// ...
}
objectsToUpdate.splice(0, objectsToUpdate.length)
} 就这些了。你可以点击 reset 按钮移除所有东西。
用 Cannon.js 走得更多
虽然我们覆盖了基础知识,而且你已经能做很多事情了,但这里有一些可以改进的地方。
约束
约束,顾名思义,能在两个 body 之间启用约束。我们这节课不会覆盖它们,但这里是约束的列表:
- HingeConstraint:像一个门铰链一样运作。
- DistanceConstraint:强制 body 彼此保持一段距离。
- LockConstraint:把 body 合并起来,就像它们是一个整体。
- PointToPointConstraint:把 body 粘到一个特定的点上。
类、方法、属性和事件
有很多类,每个类都有不同的方法、属性和事件。试着把所有类至少都浏览一遍,只是为了知道它们的存在。它可能会在你未来的项目里为你省下一些时间。
示例
文档并不完美。花些时间看看演示并做点研究,弄清楚怎么做事情会很有帮助。很多人很可能已经遇到过你可能遇到的这些问题了。不要犹豫去依赖社区。
Workers
运行物理模拟需要时间。你电脑里负责做这件事的组件是 CPU。当你运行 Three.js、Cannon.js、你的代码逻辑等等时,这一切都是在你 CPU 的同一个线程里完成的。如果有太多要做的(比如物理模拟里有太多对象),那个线程会很快过载,导致帧率下降。
正确的解决方案是使用 workers。Workers 让你把代码的一部分放到一个不同的线程里来分担负载。然后你可以从那部分代码发送和接收数据。它可以带来相当可观的性能提升。
问题是代码必须明确地被分开。你可以在这个网页源代码里找到不错而且简单的例子。
Cannon-es
正如我们之前说的,Cannon.js 已经很多年没有更新了。幸运的是,有些人 fork 了这个仓库并开始做更新。多亏了他们,我们能用一个更好且受维护的 Cannon.js 版本:
要用这个版本代替原版,在项目文件夹里打开终端(或关闭服务器),用 npm uninstall --save cannon 移除之前的 cannon.js 依赖。
至于 cannon-es,你可以用 npm install cannon-es 安装最新版本,并改变你在代码里导入 Cannon.js 的方式:
import * as CANNON from 'cannon-es' 一切都应该和之前一样正常工作。你可以在这个Git 仓库页面里查看这些改动。
最新版本应该能作为即插即用的替代品,但如果你收到错误,你可以用一个更具体的版本,比如通过运行 npm install cannon-es@0.20 使用 0.20 版本(已经测试过)。
Ammo.js
我们用 Cannon.js 是因为这个库容易实现和理解。它最大的竞争对手之一是 Ammo.js。虽然它更难使用、更难在你的项目里实现,但这里有一些可能让你感兴趣的特性:
- 它是 Bullet 的移植,Bullet 是一个知名且久经考验的、用 C++ 编写的物理引擎。
- 它支持 WebAssembly(wasm)。WebAssembly 是一种被大多数现代浏览器支持的低级语言。因为它是低级的,所以有更好的性能。
- 它更受欢迎,你能找到更多 Three.js 的示例。
- 它支持更多特性。
如果你需要最好的性能,或者你的项目有特定的特性,你可能应该选 Ammo.js 而不是 Cannon.js。
Physijs
Physijs 简化了在 Three.js 项目里物理的实现。它使用 Ammo.js,并且原生支持 workers。
- 网站:https://chandlerprall.github.io/Physijs/
- Git 仓库:https://github.com/chandlerprall/Physijs
- 文档:https://github.com/chandlerprall/Physijs/wiki
与其分别创建 Three.js 对象和物理对象,你可以同时创建两者:
box = new Physijs.BoxMesh(
new THREE.CubeGeometry(5, 5, 5),
new THREE.MeshBasicMaterial({ color: 0x888888 })
)
scene.add(box) Physijs 会处理剩下的部分。
虽然这很吸引人,尤其是对初学者,但当你想做一些库不支持的事情时,事情就变得复杂了。找出 bug 从哪里来的也可能很麻烦。
和 Ammo.js 一样,花点时间想想对你的项目来说最好的解决方案是什么。